// Copyright (c) 2013, the Dart project authors.  Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

/// This tests HTML validation and sanitization, which is very important
/// for prevent XSS or other attacks. If you suppress this, or parts of it
/// please make it a critical bug and bring it to the attention of the
/// dart:html maintainers.
library node_validator_test;

import 'dart:html';
import 'dart:svg' as svg;

import 'package:expect/legacy/minitest.dart'; // ignore: deprecated_member_use_from_same_package

import 'utils.dart';

void validateHtml(String html, String reference, NodeValidator validator) {
  var a = document.body!.createFragment(html, validator: validator);
  var b = document.body!.createFragment(
    reference,
    treeSanitizer: NodeTreeSanitizer.trusted,
  );

  // Prevent a false pass when both the html and the reference both get entirely
  // deleted, which is technically a match, but unlikely to be what we meant.
  if (reference != '') {
    expect(b.childNodes.length > 0, isTrue);
  }
  validateNodeTree(a, b);
}

class RecordingUriValidator implements UriPolicy {
  final List<String> calls = <String>[];

  bool allowsUri(String uri) {
    calls.add('$uri');
    return false;
  }

  void reset() {
    calls.clear();
  }
}

void testHtml(
  String name,
  NodeValidator validator,
  String html, [
  String? reference,
]) {
  test(name, () {
    if (reference == null) {
      reference = html;
    }

    validateHtml(html, reference!, validator);
  });
}

main() {
  group('DOM_sanitization', () {
    var validator = new NodeValidatorBuilder.common();

    testHtml(
      'allows simple constructs',
      validator,
      '<div class="baz">something</div>',
    );

    testHtml(
      'blocks unknown attributes',
      validator,
      '<div foo="baz">something</div>',
      '<div>something</div>',
    );

    testHtml(
      'blocks custom element',
      validator,
      '<x-my-element>something</x-my-element>',
      '',
    );

    testHtml(
      'blocks custom is element',
      validator,
      '<div is="x-my-element">something</div>',
      '',
    );

    testHtml(
      'blocks body elements',
      validator,
      '<body background="s"></body>',
      '',
    );

    testHtml(
      'allows select elements',
      validator,
      '<select>'
          '<option>a</option>'
          '</select>',
    );

    testHtml(
      'blocks sequential script elements',
      validator,
      '<div><script></script><script></script></div>',
      '<div></div>',
    );

    testHtml(
      'blocks inline styles',
      validator,
      '<div style="background: red"></div>',
      '<div></div>',
    );

    testHtml(
      'blocks namespaced attributes',
      validator,
      '<div ns:foo="foo"></div>',
      '<div></div>',
    );

    testHtml(
      'blocks namespaced common attributes',
      validator,
      '<div ns:class="foo"></div>',
      '<div></div>',
    );

    testHtml(
      'blocks namespaced common elements',
      validator,
      '<ns:div></ns:div>',
      '',
    );

    testHtml(
      'allows CDATA sections',
      validator,
      '<span>![CDATA[ some text ]]></span>',
    );

    testHtml(
      'backquotes not removed',
      validator,
      '<img src="dice.png" alt="``onload=xss()" />',
    );

    testHtml(
      '0x3000 not removed',
      validator,
      '<a href="&#x3000;javascript:alert(1)">CLICKME</a>',
    );

    test('sanitizes template contents', () {
      if (!TemplateElement.supported) return;

      var html =
          '<template>'
          '<div></div>'
          '<script></script>'
          '<img src="http://example.com/foo"/>'
          '</template>';

      var fragment = document.body!.createFragment(html, validator: validator);
      var template = fragment.nodes.single as TemplateElement;

      var expectedContent = document.body!.createFragment(
        '<div></div>'
        '<img/>',
      );

      validateNodeTree(template.content!, expectedContent);
    });

    test("appendHtml is sanitized", () {
      var html = '<body background="s"></body><div></div>';
      document.body!.appendHtml('<div id="stuff"></div>');
      var stuff = document.querySelector("#stuff")!;
      stuff.appendHtml(html);
      expect(stuff.childNodes.length, 1);
      stuff.remove();
    });

    test("documentFragment.appendHtml is sanitized", () {
      var html = '<div id="things></div>';
      var fragment = new DocumentFragment.html(html);
      fragment.appendHtml('<div id="bad"><script></script></div>');
      expect(fragment.childNodes.length, 1);
      var child = fragment.childNodes[0] as Element;
      expect(child.id, "bad");
      expect(child.childNodes.length, 0);
    });

    testHtml(
      "sanitizes embed",
      validator,
      "<div><embed src='' type='application/x-shockwave-flash'></embed></div>",
      "<div></div>",
    );
  });

  group('URI_sanitization', () {
    var recorder = new RecordingUriValidator();
    var validator = new NodeValidatorBuilder()..allowHtml5(uriPolicy: recorder);

    checkUriPolicyCalls(
      String name,
      String html,
      String reference,
      List<String> expectedCalls,
    ) {
      test(name, () {
        recorder.reset();

        validateHtml(html, reference, validator);
        expect(recorder.calls, expectedCalls);
      });
    }

    checkUriPolicyCalls('a::href', '<a href="s"></a>', '<a></a>', ['s']);

    checkUriPolicyCalls(
      'area::href',
      '<area href="s"></area>',
      '<area></area>',
      ['s'],
    );

    checkUriPolicyCalls(
      'blockquote::cite',
      '<blockquote cite="s"></blockquote>',
      '<blockquote></blockquote>',
      ['s'],
    );
    checkUriPolicyCalls('command::icon', '<command icon="s"/>', '<command/>', [
      's',
    ]);
    checkUriPolicyCalls('img::src', '<img src="s"/>', '<img/>', ['s']);
    checkUriPolicyCalls('input::src', '<input src="s"/>', '<input/>', ['s']);
    checkUriPolicyCalls('ins::cite', '<ins cite="s"></ins>', '<ins></ins>', [
      's',
    ]);
    checkUriPolicyCalls('q::cite', '<q cite="s"></q>', '<q></q>', ['s']);
    checkUriPolicyCalls('video::poster', '<video poster="s"/>', '<video/>', [
      's',
    ]);
  });

  group('allowNavigation', () {
    var validator = new NodeValidatorBuilder()..allowNavigation();

    testHtml('allows anchor tags', validator, '<a href="#foo">foo</a>');

    testHtml(
      'allows form elements',
      validator,
      '<form method="post" action="/foo"></form>',
    );

    testHtml(
      'disallows script navigation',
      validator,
      '<a href="javascript:foo = 1">foo</a>',
      '<a>foo</a>',
    );

    testHtml(
      'disallows cross-site navigation',
      validator,
      '<a href="http://example.com">example.com</a>',
      '<a>example.com</a>',
    );

    testHtml(
      'blocks other elements',
      validator,
      '<a href="#foo"><b>foo</b></a>',
      '<a href="#foo"></a>',
    );

    testHtml('blocks tag extension', validator, '<a is="x-foo"></a>', '');
  });

  group('allowImages', () {
    var validator = new NodeValidatorBuilder()..allowImages();

    testHtml(
      'allows images',
      validator,
      '<img src="/foo.jpg" alt="something" width="100" height="100"/>',
    );

    testHtml(
      'blocks onerror',
      validator,
      '<img src="/foo.jpg" onerror="something"/>',
      '<img src="/foo.jpg"/>',
    );

    testHtml(
      'enforces same-origin',
      validator,
      '<img src="http://example.com/foo.jpg"/>',
      '<img/>',
    );
  });

  group('allowCustomElement', () {
    var validator = new NodeValidatorBuilder()
      ..allowCustomElement('x-foo', attributes: ['bar'], uriAttributes: ['baz'])
      ..allowHtml5();

    testHtml(
      'allows custom elements',
      validator,
      '<x-foo bar="something" baz="/foo.jpg"></x-foo>',
    );

    testHtml(
      'validates custom tag URIs',
      validator,
      '<x-foo baz="http://example.com/foo.jpg"></x-foo>',
      '<x-foo></x-foo>',
    );

    testHtml('blocks type extensions', validator, '<div is="x-foo"></div>', '');

    testHtml(
      'blocks tags on non-matching elements',
      validator,
      '<div bar="foo"></div>',
      '<div></div>',
    );
  });

  group('identify Uri attributes listed as attributes', () {
    var validator = new NodeValidatorBuilder()
      ..allowElement('a', attributes: ['href']);

    testHtml(
      'reject different-origin link',
      validator,
      '<a href="http://www.google.com/foo">Google-Foo</a>',
      '<a>Google-Foo</a>',
    );
  });

  group('allowTagExtension', () {
    var validator = new NodeValidatorBuilder()
      ..allowTagExtension(
        'x-foo',
        'div',
        attributes: ['bar'],
        uriAttributes: ['baz'],
      )
      ..allowHtml5();

    testHtml(
      'allows tag extensions',
      validator,
      '<div is="x-foo" bar="something" baz="/foo.jpg"></div>',
    );

    testHtml('blocks custom elements', validator, '<x-foo></x-foo>', '');

    testHtml(
      'validates tag extension URIs',
      validator,
      '<div is="x-foo" baz="http://example.com/foo.jpg"></div>',
      '<div is="x-foo"></div>',
    );

    testHtml(
      'blocks tags on non-matching elements',
      validator,
      '<div bar="foo"></div>',
      '<div></div>',
    );

    testHtml(
      'blocks non-matching tags',
      validator,
      '<span is="x-foo">something</span>',
      '',
    );

    validator = new NodeValidatorBuilder()
      ..allowTagExtension(
        'x-foo',
        'div',
        attributes: ['bar'],
        uriAttributes: ['baz'],
      )
      ..allowTagExtension('x-else', 'div');

    testHtml(
      'blocks tags on non-matching custom elements',
      validator,
      '<div bar="foo" is="x-else"></div>',
      '<div is="x-else"></div>',
    );
  });

  group('allowTemplating', () {
    var validator = new NodeValidatorBuilder()
      ..allowTemplating()
      ..allowHtml5();

    testHtml(
      'allows templates',
      validator,
      '<template bind="{{a}}"></template>',
    );

    testHtml(
      'allows template attributes',
      validator,
      '<template bind="{{a}}" ref="foo" repeat="{{}}" if="{{}}" syntax="foo"></template>',
    );

    testHtml(
      'allows template attribute',
      validator,
      '<div template repeat="{{}}"></div>',
    );

    testHtml(
      'blocks illegal template attribute',
      validator,
      '<div template="foo" repeat="{{}}"></div>',
      '<div></div>',
    );
  });

  group('allowSvg', () {
    var validator = new NodeValidatorBuilder()
      ..allowSvg()
      ..allowTextElements();

    testHtml(
      'allows basic SVG',
      validator,
      '<svg xmlns="http://www.w3.org/2000/svg'
          'xmlns:xlink="http://www.w3.org/1999/xlink">'
          '<image xlink:href="foo" data-foo="bar"/>'
          '</svg>',
    );

    testHtml(
      'blocks script elements',
      validator,
      '<svg xmlns="http://www.w3.org/2000/svg>'
          '<script></script>'
          '</svg>',
      '',
    );

    testHtml(
      'blocks script elements but allows other',
      validator,
      '<svg xmlns="http://www.w3.org/2000/svg>'
          '<script></script><ellipse cx="200" cy="80" rx="100" ry="50"></ellipse>'
          '</svg>',
      '<svg xmlns="http://www.w3.org/2000/svg>'
          '<ellipse cx="200" cy="80" rx="100" ry="50"></ellipse>'
          '</svg>',
    );

    testHtml(
      'blocks script handlers',
      validator,
      '<svg xmlns="http://www.w3.org/2000/svg'
          'xmlns:xlink="http://www.w3.org/1999/xlink">'
          '<image xlink:href="foo" onerror="something"/>'
          '</svg>',
      '<svg xmlns="http://www.w3.org/2000/svg'
          'xmlns:xlink="http://www.w3.org/1999/xlink">'
          '<image xlink:href="foo"/>'
          '</svg>',
    );

    testHtml(
      'blocks foreignObject content',
      validator,
      '<svg xmlns="http://www.w3.org/2000/svg">'
          '<foreignobject width="100" height="150">'
          '<body xmlns="http://www.w3.org/1999/xhtml">'
          '<div>Some content</div>'
          '</body>'
          '</foreignobject>'
          '<b>42</b>'
          '</svg>',
      '<svg xmlns="http://www.w3.org/2000/svg">'
          '<b>42</b>'
          '</svg>',
    );
  });

  group('allowInlineStyles', () {
    var validator = new NodeValidatorBuilder()
      ..allowTextElements()
      ..allowInlineStyles();

    testHtml(
      'allows inline styles',
      validator,
      '<span style="background-color:red">text</span>',
    );

    testHtml(
      'blocks other attributes',
      validator,
      '<span class="red-span"></span>',
      '<span></span>',
    );

    validator = new NodeValidatorBuilder()
      ..allowTextElements()
      ..allowInlineStyles(tagName: 'span');

    testHtml(
      'scoped allows inline styles on spans',
      validator,
      '<span style="background-color:red">text</span>',
    );

    testHtml(
      'scoped blocks inline styles on LIs',
      validator,
      '<li style="background-color:red">text</li>',
      '<li>text</li>',
    );
  });

  group('throws', () {
    var validator = new NodeValidator.throws(new NodeValidatorBuilder.common());

    var validationError = throwsArgumentError;

    test('does not throw on valid syntax', () {
      expect(() {
        document.body!.createFragment('<div></div>', validator: validator);
      }, returnsNormally);
    });

    test('throws on invalid elements', () {
      expect(() {
        document.body!.createFragment('<foo></foo>', validator: validator);
      }, validationError);
    });

    test('throws on invalid attributes', () {
      expect(() {
        document.body!.createFragment(
          '<div foo="bar"></div>',
          validator: validator,
        );
      }, validationError);
    });

    test('throws on invalid attribute values', () {
      expect(() {
        document.body!.createFragment(
          '<img src="http://example.com/foo.jpg"/>',
          validator: validator,
        );
      }, validationError);
    });
  });

  group('svg', () {
    test('parsing', () {
      var svgText =
          '<svg xmlns="http://www.w3.org/2000/svg'
          'xmlns:xlink="http://www.w3.org/1999/xlink">'
          '<image xlink:href="foo" data-foo="bar"/>'
          '</svg>';

      var fragment = new DocumentFragment.svg(svgText);
      var element = fragment.nodes.first as Element;
      expect(element is svg.SvgSvgElement, isTrue);
      expect(element.children[0] is svg.ImageElement, isTrue);
    });
  });

  group('dom_clobbering', () {
    var validator = new NodeValidatorBuilder.common();

    testHtml(
      'DOM clobbering of attributes with single node',
      validator,
      "<form id='single_node_clobbering' onmouseover='alert(1)'><input name='attributes'>",
      "",
    );

    testHtml(
      'DOM clobbering of attributes with multiple nodes',
      validator,
      "<form onmouseover='alert(1)'><input name='attributes'>"
          "<input name='attributes'>",
      "",
    );

    testHtml(
      'DOM clobbering of lastChild',
      validator,
      "<form><input name='lastChild'><input onmouseover='alert(1)'>",
      "",
    );

    testHtml(
      'DOM clobbering of both children and lastChild',
      validator,
      "<form><input name='lastChild'><input name='children'>"
          "<input id='children'><input onmouseover='alert(1)'>",
      "",
    );

    testHtml(
      'DOM clobbering of both children and lastChild, different order',
      validator,
      "<form><input name='children'><input name='children'>"
          "<input id='children' name='lastChild'>"
          "<input id='bad' onmouseover='alert(1)'>",
      "",
    );

    // Walking templates triggers a recursive sanitization call, which shouldn't
    // invalidate the information collected from the previous visit of the later
    // nodes in the walk.
    testHtml(
      'DOM clobbering with recursive sanitize call using templates',
      validator,
      "<form><div>"
          "<input id=childNodes />"
          "<template></template>"
          "<input id=childNodes name=lastChild />"
          "<img id=exploitImg src=0 onerror='alert(1)' />"
          "</div></form>",
      "",
    );

    test('tagName makes containing form invalid', () {
      var fragment = document.body!.createFragment(
        "<form onmouseover='alert(2)'><input name='tagName'>",
        validator: validator,
      );
      var form = fragment.lastChild as FormElement?;
      // If the tagName was clobbered, the sanitizer should have removed
      // the whole thing and form is null.
      // If the tagName was not clobbered, then there will be content,
      // but the tagName should be the normal value. IE11 has started
      // doing this.
      if (form != null) {
        expect(form.tagName, 'FORM');
      }
    });

    test('tagName without mouseover', () {
      var fragment = document.body!.createFragment(
        "<form><input name='tagName'>",
        validator: validator,
      );
      var form = fragment.lastChild as FormElement?;
      // If the tagName was clobbered, the sanitizer should have removed
      // the whole thing and form is null.
      // If the tagName was not clobbered, then there will be content,
      // but the tagName should be the normal value.
      if (form != null) {
        expect(form.tagName, 'FORM');
      }
    });
  });
}
